Structural Patterns
# Most engineers expect this proxy to "transparently" wrap MyService.
class MyService:
def work(self):
return "done"
def __repr__(self):
return "MyService()"
class LoggingProxy:
def __init__(self, service):
self._service = service
def __getattr__(self, name):
attr = getattr(self._service, name)
if callable(attr):
def wrapper(*args, **kwargs):
print(f"Calling {name}")
return attr(*args, **kwargs)
return wrapper
return attr
proxy = LoggingProxy(MyService())
print(proxy.work()) # "Calling work" then "done" ✓ works
print(repr(proxy)) # "<__main__.LoggingProxy object at 0x...>" ✗ WRONG
print(type(proxy).__name__) # "LoggingProxy" - NOT "MyService" ✗ WRONG
The surprise: __getattr__ is only called when normal attribute lookup has already failed. Python resolves special methods (__repr__, __len__, __iter__, __class__, etc.) by looking them up directly on the type, never on the instance, so __getattr__ is bypassed entirely. Your proxy silently breaks isinstance checks, repr(), len(), iteration, and every other protocol that uses dunder methods. You must explicitly delegate each dunder, or use __getattribute__ with care. This is the central gotcha in every Python proxy and the thread that connects all structural patterns: composition is powerful, but delegation has rules.
What You Will Learn
- Why structural patterns exist and how to choose between them
- Adapter - class adapter vs object adapter, unifying LLM clients
- Bridge - decoupling two independent axes of variation
- Composite - recursive trees with full Python protocol support (
__iter__,__len__,__repr__) - Decorator pattern (the object-wrapping pattern, not
@syntax) - composable HTTP client layers - Facade - hiding subsystem complexity behind a single clean API
- Flyweight - sharing intrinsic state to cut memory,
__slots__synergy - Proxy - virtual proxy, protection proxy, remote proxy, and the
__getattr__/__getattribute__trap in full detail - How to distinguish all seven patterns by the problem they solve
- 5 interview Q&A with precise, senior-level answers
Prerequisites
- Comfortable with Python classes, inheritance, and dunder methods
- Familiar with
abc.ABCandabstractmethod - Basic understanding of creational patterns (Module 1, Lesson 01)
- Python 3.10+
Why Structural Patterns?
Structural patterns are about composition - how you assemble classes and objects into larger structures without coupling them tightly. Where creational patterns answer "how do I make this?", structural patterns answer "how do I connect and organise this so it stays maintainable as it grows?"
| Pattern | Core idea | The problem it fixes |
|---|---|---|
| Adapter | Translate one interface to another | Third-party / legacy API mismatch |
| Bridge | Separate abstraction from implementation | Combinatorial class explosion |
| Composite | Tree of uniform objects | Recursive structures: files, UIs, pipelines |
| Decorator | Wrap an object to extend behaviour | Feature layering without subclassing |
| Facade | Single entry point to a subsystem | Overwhelming, multi-library APIs |
| Flyweight | Share common state across many instances | Memory pressure from millions of small objects |
| Proxy | Surrogate that controls access | Lazy load, access control, remote objects |
A production Python service might use all seven simultaneously. A FastAPI inference service might use a Facade over external APIs, Proxy for lazy model loading, Decorator for retry/logging on HTTP clients, and Flyweight for NLP token objects.
Part 1 - Adapter
The Problem
You are building an AI platform. Three teams have integrated three different LLM providers, each with a different SDK design:
# OpenAI SDK style
import openai
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}]
)
text = response.choices[0].message.content
# Anthropic SDK style
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello"}]
)
text = response.content[0].text
# Ollama local REST API style
import requests
response = requests.post("http://localhost:11434/api/chat", json={
"model": "llama3",
"messages": [{"role": "user", "content": "Hello"}],
"stream": False,
})
text = response.json()["message"]["content"]
Three calling conventions, three response shapes. Any code that needs to swap providers, run A/B tests, or fall back on failure is littered with if provider == "openai": ... branches. Testing requires mocking three different APIs.
Object Adapter - Wrapping the Adaptee
The object adapter holds a reference to the adaptee and translates calls. This is the idiomatic Python approach.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
# ── Target interface - the one ALL application code depends on ────────────────
class LLMClient(ABC):
"""Unified interface. Application code only knows this."""
@abstractmethod
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
...
@abstractmethod
def model_name(self) -> str:
...
# ── Adaptees (third-party SDKs - cannot be modified) ─────────────────────────
class _OpenAISDK:
"""Simulates the real openai library."""
def chat_completions_create(
self,
model: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> dict[str, Any]:
return {"choices": [{"message": {"content": f"[OpenAI/{model}] response"}}]}
class _AnthropicSDK:
"""Simulates the real anthropic library."""
def messages_create(
self,
model: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> dict[str, Any]:
return {"content": [{"text": f"[Anthropic/{model}] response"}]}
class _OllamaHTTP:
"""Simulates local Ollama REST API."""
def post(self, endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
model = payload.get("model", "unknown")
return {"message": {"content": f"[Ollama/{model}] response"}}
# ── Object Adapters - translate adaptee → target ──────────────────────────────
class OpenAIAdapter(LLMClient):
def __init__(self, model: str = "gpt-4o") -> None:
self._sdk = _OpenAISDK() # composition: HAS-A adaptee
self._model = model
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._sdk.chat_completions_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["choices"][0]["message"]["content"]
def model_name(self) -> str:
return self._model
class AnthropicAdapter(LLMClient):
def __init__(self, model: str = "claude-opus-4-6") -> None:
self._sdk = _AnthropicSDK()
self._model = model
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._sdk.messages_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["content"][0]["text"]
def model_name(self) -> str:
return self._model
class OllamaAdapter(LLMClient):
def __init__(
self,
model: str = "llama3",
base_url: str = "http://localhost:11434",
) -> None:
self._http = _OllamaHTTP()
self._model = model
self._base_url = base_url
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
raw = self._http.post(
f"{self._base_url}/api/chat",
{
"model": self._model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
},
)
return raw["message"]["content"]
def model_name(self) -> str:
return self._model
Application code is now fully provider-agnostic:
def run_pipeline(client: LLMClient, prompt: str) -> str:
print(f"Using: {client.model_name()}")
return client.complete(prompt)
clients: list[LLMClient] = [
OpenAIAdapter("gpt-4o"),
AnthropicAdapter("claude-opus-4-6"),
OllamaAdapter("llama3"),
]
for c in clients:
result = run_pipeline(c, "Explain transformers in one sentence.")
print(result)
print()
Swapping providers in tests is trivial - inject any LLMClient. This is also why the Adapter pattern makes production A/B testing and fallback routing clean:
class FallbackAdapter(LLMClient):
"""Tries primary; falls back to secondary on any exception."""
def __init__(self, primary: LLMClient, secondary: LLMClient) -> None:
self._primary = primary
self._secondary = secondary
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
try:
return self._primary.complete(prompt, max_tokens=max_tokens)
except Exception as exc:
print(f"[FALLBACK] Primary {self._primary.model_name()} failed: {exc}")
return self._secondary.complete(prompt, max_tokens=max_tokens)
def model_name(self) -> str:
return f"{self._primary.model_name()}→{self._secondary.model_name()}"
resilient = FallbackAdapter(
primary=OpenAIAdapter("gpt-4o"),
secondary=OllamaAdapter("llama3"),
)
print(resilient.complete("Hello"))
Class Adapter - Using Multiple Inheritance
The class adapter uses multiple inheritance to simultaneously be an instance of both the target and the adaptee. Useful when you need access to protected/private methods of the adaptee.
class OpenAIClassAdapter(_OpenAISDK, LLMClient):
"""IS-A OpenAISDK and IS-A LLMClient simultaneously."""
def __init__(self, model: str = "gpt-4o") -> None:
self._model = model
# No _sdk attribute needed; we inherit the methods directly
def complete(self, prompt: str, *, max_tokens: int = 512) -> str:
# Calls inherited _OpenAISDK method directly
raw = self.chat_completions_create(
model=self._model,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
)
return raw["choices"][0]["message"]["content"]
def model_name(self) -> str:
return self._model
adapter = OpenAIClassAdapter()
print(adapter.complete("Hello")) # works as LLMClient
print(isinstance(adapter, _OpenAISDK)) # True - also IS the SDK
Object vs Class Adapter
| Dimension | Object Adapter | Class Adapter |
|---|---|---|
| Mechanism | Composition (HAS-A adaptee) | Multiple inheritance (IS-A adaptee) |
| Adapts subclasses? | Yes - wrap any subclass | No - locked to one concrete adaptee |
| Access protected members | No | Yes (inherited) |
| Runtime flexibility | Swap adaptee at runtime | Fixed at class definition |
| Python idiomatic | Preferred in most cases | Use only when private access is needed |
| Testability | Easy - inject mock adaptee | Harder - inheritance is static |
Rule of thumb: default to the object adapter. It follows composition-over-inheritance and lets you swap adaptees at runtime.
Part 2 - Bridge
The Problem
You need a notification system. You have notification channels (Email, SMS, Push) and message formats (plain text, HTML, JSON). The naive approach spawns a subclass per combination:
class PlainEmailNotifier: ...
class HTMLEmailNotifier: ...
class JSONEmailNotifier: ...
class PlainSMSNotifier: ...
class HTMLSMSNotifier: ...
class JSONSMSNotifier: ...
class PlainPushNotifier: ...
class HTMLPushNotifier: ...
class JSONPushNotifier: ...
# 3 channels × 3 formats = 9 classes already
Add a Slack channel: +3 classes. Add a Markdown format: +3 classes. At 5 × 5 you have 25 subclasses, most sharing identical formatting logic. This is the combinatorial explosion that Bridge prevents.
The Solution
Bridge splits the hierarchy into two independent dimensions:
- Abstraction - what the client uses (Notifier, the "what")
- Implementation - how the work gets done (MessageFormatter, the "how")
Each dimension grows independently.
from __future__ import annotations
from abc import ABC, abstractmethod
import json
# ── Implementation hierarchy - the "how" ─────────────────────────────────────
class MessageFormatter(ABC):
"""Implementor interface."""
@abstractmethod
def format(self, subject: str, body: str, metadata: dict) -> str:
...
class PlainTextFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return f"Subject: {subject}\n\n{body}"
class HTMLFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return (
f"<html><head><title>{subject}</title></head>"
f"<body><h1>{subject}</h1><p>{body}</p></body></html>"
)
class JSONFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return json.dumps({"subject": subject, "body": body, **metadata}, indent=2)
class MarkdownFormatter(MessageFormatter):
def format(self, subject: str, body: str, metadata: dict) -> str:
return f"# {subject}\n\n{body}"
# ── Abstraction hierarchy - the "what" ───────────────────────────────────────
class Notifier(ABC):
"""Abstraction - holds a reference to an implementation (the bridge)."""
def __init__(self, formatter: MessageFormatter) -> None:
self._formatter = formatter # ← this IS the bridge
@abstractmethod
def send(self, subject: str, body: str, recipient: str) -> None:
...
def _build_message(self, subject: str, body: str, metadata: dict) -> str:
return self._formatter.format(subject, body, metadata)
class EmailNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"to": recipient})
print(f"[EMAIL → {recipient}]\n{message}\n")
class SMSNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
# SMS is length-limited: truncate body
message = self._build_message(subject, body[:160], {"channel": "sms"})
print(f"[SMS → {recipient}] {message[:160]}\n")
class PushNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"device_token": recipient})
print(f"[PUSH → {recipient}]\n{message}\n")
class SlackNotifier(Notifier):
def send(self, subject: str, body: str, recipient: str) -> None:
message = self._build_message(subject, body, {"channel": recipient})
print(f"[SLACK → {recipient}]\n{message}\n")
Now mix and match freely - N + M classes instead of N × M:
# At startup: wire up formatters
html_fmt = HTMLFormatter()
json_fmt = JSONFormatter()
plain_fmt = PlainTextFormatter()
md_fmt = MarkdownFormatter()
# Different channels with different formats - no new subclasses needed
email_html = EmailNotifier(html_fmt)
sms_plain = SMSNotifier(plain_fmt)
push_json = PushNotifier(json_fmt)
slack_md = SlackNotifier(md_fmt)
notification = {
"subject": "Your order shipped",
"body": "Your package will arrive by Friday.",
}
for notifier in [email_html, sms_plain, push_json, slack_md]:
notifier.send(**notification)
Switching Implementations at Runtime
Because the formatter is stored as an attribute, you can swap it at runtime - something impossible with inheritance:
email = EmailNotifier(plain_fmt)
# Switch to HTML for premium users
email._formatter = html_fmt
Bridge vs Adapter
| Dimension | Bridge | Adapter |
|---|---|---|
| Design time | Designed upfront to vary independently | Retrofitted to fix incompatibility |
| Intent | Enable independent variation of two axes | Make one interface look like another |
| Controls both sides | Yes | Only the adapter side |
| Relationship | Abstraction uses implementation | Adapter wraps adaptee |
| Class count | N + M | One adapter per adaptee |
Part 3 - Composite
The Problem
File systems, ML pipeline DAGs, permission groups, and UI component trees all share one structure: leaves and containers are treated uniformly. Without Composite, client code checks isinstance at every recursive call:
def total_size(node):
if isinstance(node, File): # leaf case
return node.size_bytes
elif isinstance(node, Directory): # container case
return sum(total_size(child) for child in node.children)
else:
raise TypeError(f"Unknown node: {type(node)}")
This breaks open/closed principle: adding a SymLink type forces you to update total_size everywhere.
The Solution
Define a common Component interface. Both leaves (File) and composites (Directory) implement it. Composites delegate to their children recursively.
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Iterator
class FileSystemComponent(ABC):
"""Uniform interface for both files and directories."""
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def size(self) -> int:
"""Total size in bytes."""
...
@abstractmethod
def __iter__(self) -> Iterator[FileSystemComponent]:
"""Iterate over all nodes in the subtree, including self."""
...
@abstractmethod
def __repr__(self) -> str:
...
def __len__(self) -> int:
"""Total number of nodes in the subtree (including self)."""
return sum(1 for _ in self)
# ── Leaf ──────────────────────────────────────────────────────────────────────
class File(FileSystemComponent):
def __init__(self, name: str, size_bytes: int) -> None:
super().__init__(name)
self._size = size_bytes
def size(self) -> int:
return self._size
def __iter__(self) -> Iterator[FileSystemComponent]:
yield self # A file is a single-element "tree"
def __repr__(self) -> str:
return f"File({self.name!r}, {self._size:,}B)"
# ── Composite ─────────────────────────────────────────────────────────────────
class Directory(FileSystemComponent):
def __init__(self, name: str) -> None:
super().__init__(name)
self._children: list[FileSystemComponent] = []
def add(self, component: FileSystemComponent) -> Directory:
self._children.append(component)
return self # fluent interface
def remove(self, component: FileSystemComponent) -> None:
self._children.remove(component)
def size(self) -> int:
return sum(child.size() for child in self._children)
def __iter__(self) -> Iterator[FileSystemComponent]:
yield self
for child in self._children:
yield from child # recursive delegation
def __repr__(self) -> str:
return f"Directory({self.name!r}, {len(self._children)} children)"
# ── Build a tree ──────────────────────────────────────────────────────────────
root = Directory("project")
src = Directory("src")
tests = Directory("tests")
src.add(File("main.py", 4_200)).add(File("utils.py", 1_800))
tests.add(File("test_main.py", 900)).add(File("conftest.py", 300))
root.add(src).add(tests).add(File("README.md", 512))
print(f"Total size: {root.size():,} bytes") # 7,712 bytes
print(f"Total nodes: {len(root)}") # 8 (3 dirs + 5 files)
print()
# Client code treats leaves and composites uniformly
for component in root:
prefix = " " if isinstance(component, File) else ""
print(f"{prefix}{component!r}")
ML Pipeline Composite
The same pattern models ML pipeline stages where stages can be nested groups:
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class PipelineStage(ABC):
def __init__(self, name: str) -> None:
self.name = name
@abstractmethod
def run(self, data: Any) -> Any:
...
def __repr__(self) -> str:
return f"{type(self).__name__}({self.name!r})"
class TransformStage(PipelineStage):
def __init__(self, name: str, fn) -> None:
super().__init__(name)
self._fn = fn
def run(self, data: Any) -> Any:
print(f" [{self.name}]")
return self._fn(data)
class PipelineGroup(PipelineStage):
"""Composite - runs children sequentially, passing output as next input."""
def __init__(self, name: str) -> None:
super().__init__(name)
self._stages: list[PipelineStage] = []
def add(self, stage: PipelineStage) -> PipelineGroup:
self._stages.append(stage)
return self
def run(self, data: Any) -> Any:
print(f"[Group: {self.name}]")
for stage in self._stages:
data = stage.run(data)
return data
# Nested groups work because PipelineGroup IS-A PipelineStage
preprocessing = (
PipelineGroup("preprocessing")
.add(TransformStage("tokenize", str.split))
.add(TransformStage("lowercase", lambda tokens: [t.lower() for t in tokens]))
.add(TransformStage("strip_punct", lambda tokens: [t.strip(".,!?") for t in tokens]))
)
full_pipeline = (
PipelineGroup("inference_pipeline")
.add(preprocessing) # composite inside composite
.add(TransformStage("embed", lambda tokens: f"<embedding of {len(tokens)} tokens>"))
.add(TransformStage("classify", lambda emb: {"label": "positive", "input": emb}))
)
result = full_pipeline.run("Hello, World! This is Python.")
print(result)
Composite Protocol Implementation Checklist
| Method | Responsibility | Leaf behaviour | Composite behaviour |
|---|---|---|---|
size() / count() | Aggregate metric | Return own value | Sum over children |
__iter__ | Flat iteration over all nodes | yield self | yield self; yield from child |
__len__ | Total node count | 1 | Derived from __iter__ |
__repr__ | Debug display | Show name + data | Show name + child count |
add() / remove() | Manage children | Raise TypeError | Append / remove |
Part 4 - Decorator Pattern
Critical Distinction: Pattern vs Syntax
Python's @decorator syntax wraps callables at definition time. The GoF Decorator pattern wraps object instances at runtime, with both wrapper and wrapped implementing the same interface. They share an idea but are not the same thing.
# Python @syntax - wraps a FUNCTION, returns a new callable
def logged(fn):
def wrapper(*args, **kwargs):
print(f"Calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapper
@logged
def process(x): return x * 2 # process is now wrapper
# GoF Decorator pattern - wraps an OBJECT, same interface
class LoggedHTTPClient: # implements HTTPClient
def __init__(self, client): # wraps another HTTPClient
self._client = client
def get(self, url): # delegates + extends
print(f"GET {url}")
return self._client.get(url)
The GoF version is what allows stacking - wrapping a wrapper which wraps a wrapper, all transparently via a shared interface.
The Problem
You have an HTTPClient. Different callers need different cross-cutting behaviours:
- Team A: logging only
- Team B: retry on 5xx only
- Team C: rate limiting only
- Team D: all three, in a specific order
With subclassing you need: LoggedClient, RetryClient, RateLimitedClient, LoggedRetryClient, LoggedRateLimitedClient, RetryRateLimitedClient, LoggedRetryRateLimitedClient - seven subclasses for three features.
The Solution - Composable Object Decorators
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from time import sleep, monotonic
import random
@dataclass
class Response:
status: int
body: str
class HTTPClient(ABC):
@abstractmethod
def get(self, url: str) -> Response: ...
@abstractmethod
def post(self, url: str, data: dict) -> Response: ...
# ── Concrete implementation ───────────────────────────────────────────────────
class RealHTTPClient(HTTPClient):
def get(self, url: str) -> Response:
# Simulate 30% failure rate
if random.random() < 0.3:
return Response(500, "Internal Server Error")
return Response(200, f"GET {url} body")
def post(self, url: str, data: dict) -> Response:
return Response(201, f"POST {url} created")
# ── Base decorator - transparent delegation ───────────────────────────────────
class HTTPClientDecorator(HTTPClient):
"""
Base class for all decorators. Override only what you need;
everything else passes through unchanged.
"""
def __init__(self, wrapped: HTTPClient) -> None:
self._wrapped = wrapped
def get(self, url: str) -> Response:
return self._wrapped.get(url)
def post(self, url: str, data: dict) -> Response:
return self._wrapped.post(url, data)
# ── Concrete decorators ───────────────────────────────────────────────────────
class LoggingDecorator(HTTPClientDecorator):
def get(self, url: str) -> Response:
print(f"[LOG] --> GET {url}")
response = super().get(url)
print(f"[LOG] <-- {response.status}")
return response
def post(self, url: str, data: dict) -> Response:
print(f"[LOG] --> POST {url} {data}")
response = super().post(url, data)
print(f"[LOG] <-- {response.status}")
return response
class RetryDecorator(HTTPClientDecorator):
def __init__(
self,
wrapped: HTTPClient,
max_retries: int = 3,
backoff_base: float = 0.05,
) -> None:
super().__init__(wrapped)
self._max_retries = max_retries
self._backoff_base = backoff_base
def get(self, url: str) -> Response:
response = Response(500, "")
for attempt in range(self._max_retries):
response = super().get(url)
if response.status < 500:
return response
wait = self._backoff_base * (2 ** attempt)
print(f"[RETRY] Attempt {attempt + 1}/{self._max_retries} failed ({response.status}), wait {wait:.2f}s")
sleep(wait)
return response
def post(self, url: str, data: dict) -> Response:
# POST is not idempotent - do not retry
return super().post(url, data)
class RateLimitDecorator(HTTPClientDecorator):
def __init__(self, wrapped: HTTPClient, calls_per_second: float = 5.0) -> None:
super().__init__(wrapped)
self._min_interval = 1.0 / calls_per_second
self._last_call: float = 0.0
def _throttle(self) -> None:
elapsed = monotonic() - self._last_call
if elapsed < self._min_interval:
sleep(self._min_interval - elapsed)
self._last_call = monotonic()
def get(self, url: str) -> Response:
self._throttle()
return super().get(url)
def post(self, url: str, data: dict) -> Response:
self._throttle()
return super().post(url, data)
# ── Compose decorators ────────────────────────────────────────────────────────
# Read inside-out: RateLimit is innermost, Logging is outermost
client: HTTPClient = LoggingDecorator(
RetryDecorator(
RateLimitDecorator(
RealHTTPClient(),
calls_per_second=10.0,
),
max_retries=3,
)
)
response = client.get("https://api.example.com/users")
print(response)
Execution Order Matters
Decorators execute in the order they are stacked. The call passes inward through each layer and the response passes outward:
client.get("url")
LoggingDecorator.get() → logs "GET url"
RetryDecorator.get() → tries up to 3 times
RateLimitDecorator.get() → throttles
RealHTTPClient.get() → makes actual request
RateLimitDecorator returns
RetryDecorator: if 5xx, retry; else return
LoggingDecorator logs response status
client receives response
If you want to log each retry attempt, nest Logging inside Retry. If you want only the final outcome logged, keep Logging outside. This fine control is impossible with static inheritance.
Composing with a Factory Function
For teams that find the nested constructor calls hard to read, a factory function is clean:
def build_client(
base_url: str,
*,
logging: bool = True,
retries: int = 3,
rate_limit: float = 5.0,
) -> HTTPClient:
client: HTTPClient = RealHTTPClient()
client = RateLimitDecorator(client, calls_per_second=rate_limit)
client = RetryDecorator(client, max_retries=retries)
if logging:
client = LoggingDecorator(client)
return client
production_client = build_client("https://api.example.com", retries=5)
test_client = build_client("http://localhost:8001", logging=False, rate_limit=100.0)
Part 5 - Facade
The Problem
A document intelligence pipeline needs to:
- Extract text from PDFs (pdfplumber)
- Run OCR on scanned pages (pytesseract)
- Detect the document's language (langdetect)
- Generate a semantic embedding (sentence-transformers)
Each library has its own API style, error handling, and configuration surface. A controller function that coordinates all four quickly becomes a 200-line tangle of library-specific logic. Every caller has to know all four APIs.
The Solution
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
# ── Subsystem components (simulating real library wrappers) ───────────────────
class PDFExtractor:
"""Thin wrapper around pdfplumber."""
def extract_text(self, path: Path) -> str:
print(f"[PDF] Extracting text from {path.name}")
# Real: with pdfplumber.open(path) as pdf: ...
return "The attention mechanism allows models to focus on relevant parts of the input."
def page_count(self, path: Path) -> int:
# Real: with pdfplumber.open(path) as pdf: return len(pdf.pages)
return 8
def get_page_image(self, path: Path, page: int) -> bytes:
# Real: convert page to image bytes for OCR
return b"<image bytes>"
class OCREngine:
"""Thin wrapper around pytesseract."""
def ocr_image(self, image_bytes: bytes) -> str:
print("[OCR] Running Tesseract on page image")
return "Text recovered via OCR from scanned page."
def needs_ocr(self, text: str) -> bool:
# Heuristic: extracted text suspiciously short → likely a scanned page
return len(text.strip()) < 80
class LanguageDetector:
"""Thin wrapper around langdetect."""
def detect(self, text: str) -> str:
print("[LANG] Detecting language")
# Real: return langdetect.detect(text)
return "en"
def detect_with_confidence(self, text: str) -> tuple[str, float]:
# Real: from langdetect import detect_langs; ...
return ("en", 0.997)
class EmbeddingModel:
"""Thin wrapper around sentence-transformers."""
def __init__(self, model_name: str = "all-MiniLM-L6-v2") -> None:
print(f"[EMBED] Loading model '{model_name}'...")
self._model_name = model_name
# Real: self._model = SentenceTransformer(model_name)
print("[EMBED] Ready.")
def encode(self, text: str) -> list[float]:
print(f"[EMBED] Encoding {len(text)} characters")
# Real: return self._model.encode(text).tolist()
return [round(hash(text[i:i+2]) % 1000 / 1000, 4) for i in range(0, min(len(text), 384), 2)]
# ── Result dataclass ──────────────────────────────────────────────────────────
@dataclass
class ProcessedDocument:
source_path: str
text: str
language: str
language_confidence: float
embedding: list[float]
page_count: int
ocr_applied: bool
word_count: int = field(init=False)
def __post_init__(self) -> None:
self.word_count = len(self.text.split())
def summary(self) -> str:
return (
f"Path: {self.source_path}\n"
f"Pages: {self.page_count}\n"
f"Words: {self.word_count:,}\n"
f"Language: {self.language} ({self.language_confidence:.1%})\n"
f"OCR applied: {self.ocr_applied}\n"
f"Embedding: {len(self.embedding)} dims"
)
# ── The Facade ────────────────────────────────────────────────────────────────
class DocumentProcessor:
"""
Facade over PDF extraction, OCR, language detection, and embedding.
Callers see one method: process(path) → ProcessedDocument.
All subsystem complexity is hidden.
"""
def __init__(self, embedding_model: str = "all-MiniLM-L6-v2") -> None:
# Facade coordinates (and owns) its subsystems
self._pdf = PDFExtractor()
self._ocr = OCREngine()
self._lang = LanguageDetector()
self._embed = EmbeddingModel(embedding_model)
def process(self, path: str | Path) -> ProcessedDocument:
path = Path(path)
if path.suffix.lower() != ".pdf":
raise ValueError(f"Expected a .pdf file, got: {path.suffix!r}")
# Step 1: Extract text
text = self._pdf.extract_text(path)
page_count = self._pdf.page_count(path)
ocr_applied = False
# Step 2: OCR fallback for scanned / low-text pages
if self._ocr.needs_ocr(text):
print("[FACADE] Short text detected - attempting OCR fallback")
image_bytes = self._pdf.get_page_image(path, page=0)
ocr_text = self._ocr.ocr_image(image_bytes)
if ocr_text:
text = ocr_text
ocr_applied = True
# Step 3: Language detection
language, confidence = self._lang.detect_with_confidence(text)
# Step 4: Semantic embedding
embedding = self._embed.encode(text)
return ProcessedDocument(
source_path=str(path),
text=text,
language=language,
language_confidence=confidence,
embedding=embedding,
page_count=page_count,
ocr_applied=ocr_applied,
)
def batch_process(self, paths: list[str | Path]) -> list[ProcessedDocument]:
"""Process multiple documents, collecting errors without stopping."""
results = []
for path in paths:
try:
results.append(self.process(path))
except Exception as exc:
print(f"[FACADE] Skipping {path}: {exc}")
return results
# ── Usage - caller knows nothing about pdfplumber, pytesseract, etc. ──────────
processor = DocumentProcessor("all-MiniLM-L6-v2")
doc = processor.process("attention_is_all_you_need.pdf")
print(doc.summary())
Keeping the Facade Thin
The Facade can become an anti-pattern (a "god class") if it:
- Absorbs business logic instead of delegating to subsystems
- Hides configurability that power users legitimately need
- Groups unrelated subsystems just because someone wanted "one class"
The fix: expose subsystems for power users while keeping the Facade for common cases.
# Power users can bypass the Facade and access subsystems directly
extractor = PDFExtractor()
raw_text = extractor.extract_text(Path("doc.pdf"))
# Common users get the clean API
processor = DocumentProcessor()
doc = processor.process("doc.pdf")
Facade vs Adapter
| Facade | Adapter | |
|---|---|---|
| Number of classes wrapped | Many (a whole subsystem) | One (a single adaptee) |
| Intent | Simplification | Compatibility |
| New interface | Simplified, purpose-built | Matches an existing target interface |
| Both sides you control | Usually yes | Often no (adaptee is third-party) |
Part 6 - Flyweight
The Problem
An NLP pipeline processes millions of documents. Each document contains thousands of tokens. Naively, each token occurrence is a full Python object with its string, POS tag, and lemma stored per instance - even though "the" appears ten million times with the same POS tag and lemma every time.
# Naive - new object per occurrence
from dataclasses import dataclass
@dataclass
class Token:
text: str
pos: str # Part-of-speech, same for all "the"s
lemma: str # Lemmatized form, same for all "the"s
doc_id: int
position: int
# 10 million occurrences of "the" → 10 million objects with identical text/pos/lemma
# sys.getsizeof per object ≈ 200 bytes → ~2 GB just for "the"
The Solution - Intrinsic / Extrinsic Split
Flyweight separates:
- Intrinsic state - what is the same across all occurrences:
text,pos,lemma. Stored in a shared pool. - Extrinsic state - what varies per context:
doc_id,position. Passed in by the caller, never stored in the flyweight.
from __future__ import annotations
import sys
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class TokenFlyweight:
"""
Intrinsic state only. Shared across all occurrences.
frozen=True: immutable and hashable - safe for sharing.
slots=True: eliminates __dict__, cuts memory ~3×.
"""
text: str
pos: str
lemma: str
# Class-level pool: key → shared instance
_pool: ClassVar[dict[tuple[str, str, str], "TokenFlyweight"]] = {}
def __new__(cls, text: str, pos: str, lemma: str) -> "TokenFlyweight":
key = (text, pos, lemma)
if key not in cls._pool:
instance = object.__new__(cls)
cls._pool[key] = instance
return cls._pool[key] # always return the shared instance
@classmethod
def pool_size(cls) -> int:
return len(cls._pool)
@classmethod
def clear_pool(cls) -> None:
cls._pool.clear()
@dataclass
class TokenOccurrence:
"""Extrinsic state lives here - one per actual occurrence."""
flyweight: TokenFlyweight # shared reference - cheap
doc_id: int
sentence_idx: int
token_idx: int
@property
def text(self) -> str:
return self.flyweight.text
@property
def pos(self) -> str:
return self.flyweight.pos
@property
def lemma(self) -> str:
return self.flyweight.lemma
# ── Simulate corpus processing ────────────────────────────────────────────────
def process_corpus(
docs: list[list[tuple[str, str, str]]]
) -> list[list[TokenOccurrence]]:
result = []
for doc_id, tokens in enumerate(docs):
occurrences = []
for tok_idx, (text, pos, lemma) in enumerate(tokens):
fw = TokenFlyweight(text, pos, lemma) # pool lookup or creation
occ = TokenOccurrence(
flyweight=fw,
doc_id=doc_id,
sentence_idx=0,
token_idx=tok_idx,
)
occurrences.append(occ)
result.append(occurrences)
return result
corpus = [
[("the", "DT", "the"), ("cat", "NN", "cat"), ("sat", "VBD", "sit")],
[("the", "DT", "the"), ("dog", "NN", "dog"), ("sat", "VBD", "sit")],
[("the", "DT", "the"), ("cat", "NN", "cat"), ("ran", "VBD", "run")],
]
docs = process_corpus(corpus)
total_occurrences = sum(len(d) for d in docs)
unique_flyweights = TokenFlyweight.pool_size()
print(f"Total token occurrences: {total_occurrences}") # 9
print(f"Unique flyweight objects: {unique_flyweights}") # 5 (the, cat, sat, dog, ran)
# Verify sharing: two different "the" occurrences share the same object
t1 = docs[0][0] # "the" in doc 0
t2 = docs[2][0] # "the" in doc 2
print(f"Same flyweight? {t1.flyweight is t2.flyweight}") # True
print(f"Different context? {t1.doc_id != t2.doc_id}") # True
__slots__ Memory Savings
import sys
class WithDict:
def __init__(self, text, pos, lemma):
self.text = text
self.pos = pos
self.lemma = lemma
class WithSlots:
__slots__ = ("text", "pos", "lemma")
def __init__(self, text, pos, lemma):
self.text = text
self.pos = pos
self.lemma = lemma
a = WithDict("the", "DT", "the")
b = WithSlots("the", "DT", "the")
# sys.getsizeof does not include referenced string objects, but shows base object cost
print(f"With __dict__ (base + dict): {sys.getsizeof(a) + sys.getsizeof(a.__dict__)} bytes")
print(f"With __slots__: {sys.getsizeof(b)} bytes")
# Typical: 344 bytes vs 72 bytes - nearly 5× savings per object
Intrinsic vs Extrinsic State Reference
| Property | Intrinsic | Extrinsic |
|---|---|---|
| Definition | Shared, context-independent data | Context-dependent data |
| Stored in | The flyweight (pool) | The occurrence/client |
| Mutability | Must be immutable | Can be mutable |
| Examples | Token text, POS, lemma | Doc ID, sentence index, position |
| Python mechanism | frozen=True, __slots__ | Regular dataclass / dict |
Embedding Cache as Flyweight
from __future__ import annotations
from functools import lru_cache
from typing import ClassVar
class EmbeddingFlyweight:
"""
Cache embeddings by text hash - same text always produces same embedding.
This is the Flyweight pattern using Python's built-in lru_cache.
"""
_cache: ClassVar[dict[str, list[float]]] = {}
@classmethod
def get_embedding(cls, text: str) -> list[float]:
if text not in cls._cache:
print(f"[EMBED] Computing new embedding for: {text[:40]}...")
# Real: cls._cache[text] = model.encode(text).tolist()
cls._cache[text] = [ord(c) / 1000 for c in text[:10]]
return cls._cache[text] # shared list - callers should not mutate
@classmethod
def cache_size(cls) -> int:
return len(cls._cache)
# Same text → same embedding object
e1 = EmbeddingFlyweight.get_embedding("attention is all you need")
e2 = EmbeddingFlyweight.get_embedding("attention is all you need")
print(f"Same object: {e1 is e2}") # True
print(f"Cache size: {EmbeddingFlyweight.cache_size()}")
Part 7 - Proxy
Virtual Proxy - Lazy Loading a Heavy ML Model
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional
class TextClassifier(ABC):
@abstractmethod
def predict(self, text: str) -> str: ...
@abstractmethod
def batch_predict(self, texts: list[str]) -> list[str]: ...
class HeavyBERTClassifier(TextClassifier):
"""Simulates a model that takes significant time/memory to load."""
def __init__(self) -> None:
print("[MODEL] Loading BERT weights... (30 seconds in production)")
# Real: self._model = AutoModelForSequenceClassification.from_pretrained(...)
self._model = "bert-base-uncased"
print("[MODEL] Ready.")
def predict(self, text: str) -> str:
return f"positive (confidence=0.94, model={self._model})"
def batch_predict(self, texts: list[str]) -> list[str]:
return [self.predict(t) for t in texts]
class LazyClassifierProxy(TextClassifier):
"""
Virtual proxy - defers model loading until the first actual call.
From the caller's perspective this IS a TextClassifier.
"""
def __init__(self, factory=HeavyBERTClassifier) -> None:
self._factory = factory
self._real: Optional[TextClassifier] = None
def _ensure_loaded(self) -> TextClassifier:
if self._real is None:
print("[PROXY] First request received - initialising model")
self._real = self._factory()
return self._real
def predict(self, text: str) -> str:
return self._ensure_loaded().predict(text)
def batch_predict(self, texts: list[str]) -> list[str]:
return self._ensure_loaded().batch_predict(texts)
@property
def is_loaded(self) -> bool:
return self._real is not None
# Application startup - instant, no model loaded yet
print("=== Application starting ===")
classifier: TextClassifier = LazyClassifierProxy()
print("Proxy created - model NOT yet loaded\n")
# Simulate: 5 seconds later, first real request
print("=== First inference request ===")
result = classifier.predict("This product is absolutely amazing!")
print(result)
Protection Proxy - Role-Based Database Access
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum, auto
class Permission(Enum):
READ = auto()
WRITE = auto()
DELETE = auto()
ADMIN = auto()
@dataclass(frozen=True)
class User:
username: str
permissions: frozenset[Permission]
def can(self, perm: Permission) -> bool:
return perm in self.permissions
class Database(ABC):
@abstractmethod
def query(self, sql: str) -> list[dict]: ...
@abstractmethod
def execute(self, sql: str) -> int: ...
@abstractmethod
def drop_table(self, table: str) -> None: ...
class RealDatabase(Database):
def query(self, sql: str) -> list[dict]:
print(f"[DB] Query: {sql[:60]}")
return [{"id": 1, "name": "result_row"}]
def execute(self, sql: str) -> int:
print(f"[DB] Execute: {sql[:60]}")
return 1
def drop_table(self, table: str) -> None:
print(f"[DB] DROP TABLE {table}")
class ProtectionProxy(Database):
"""
Protection proxy - enforces role-based access control.
The real database never needs to know about permissions.
"""
def __init__(self, db: Database, user: User) -> None:
self._db = db
self._user = user
def _check(self, perm: Permission) -> None:
if not self._user.can(perm):
raise PermissionError(
f"User '{self._user.username}' requires {perm.name} permission"
)
def query(self, sql: str) -> list[dict]:
self._check(Permission.READ)
return self._db.query(sql)
def execute(self, sql: str) -> int:
self._check(Permission.WRITE)
return self._db.execute(sql)
def drop_table(self, table: str) -> None:
self._check(Permission.ADMIN)
self._db.drop_table(table)
# Admin user
admin = User("alice", frozenset({Permission.READ, Permission.WRITE, Permission.ADMIN}))
admin_db = ProtectionProxy(RealDatabase(), admin)
admin_db.query("SELECT * FROM users LIMIT 10")
admin_db.drop_table("stale_cache")
# Read-only analyst
analyst = User("bob", frozenset({Permission.READ}))
analyst_db = ProtectionProxy(RealDatabase(), analyst)
analyst_db.query("SELECT COUNT(*) FROM events")
try:
analyst_db.drop_table("events") # should raise
except PermissionError as e:
print(f"Blocked: {e}")
The __getattr__ Trap - Full Explanation
This brings us back to the surprising snippet that opened this lesson. Here is the complete picture:
class Service:
tag = "service-v1"
def __repr__(self):
return "Service(tag=service-v1)"
def __len__(self):
return 42
def work(self):
return "done"
class NaiveProxy:
def __init__(self, obj):
self._obj = obj
def __getattr__(self, name):
# Called ONLY when normal lookup fails on the instance
print(f" __getattr__ called for: {name!r}")
return getattr(self._obj, name)
proxy = NaiveProxy(Service())
# These DO go through __getattr__ (instance attributes not found on NaiveProxy)
print(proxy.work()) # __getattr__ called for 'work', returns "done"
print(proxy.tag) # __getattr__ called for 'tag', returns "service-v1"
# These DO NOT go through __getattr__ - Python looks up on type(proxy)
print(repr(proxy)) # NaiveProxy's __repr__ (inherited from object), NOT "Service(tag=service-v1)"
print(len(proxy)) # TypeError: object of type 'NaiveProxy' has no len()
Why? Python's data model specifies that implicit invocation of special methods (all the __dunder__ methods) bypasses normal instance lookup and goes directly to type(obj).__dunder__. This is an intentional CPython optimisation: if Python had to call __getattr__ for every len() or repr(), the overhead would be enormous. The consequence is that __getattr__ is never consulted for implicit dunder calls.
The fix - explicit dunder forwarding:
class TransparentProxy:
"""
A proxy that properly delegates both regular attributes and dunder methods.
Uses object.__setattr__ / object.__getattribute__ to avoid infinite recursion.
"""
def __init__(self, obj: object) -> None:
# Use object.__setattr__ to bypass our own __setattr__
object.__setattr__(self, "_obj", obj)
# Regular attribute access
def __getattr__(self, name: str):
return getattr(object.__getattribute__(self, "_obj"), name)
def __setattr__(self, name: str, value) -> None:
if name == "_obj":
object.__setattr__(self, name, value)
else:
setattr(object.__getattribute__(self, "_obj"), name, value)
# Explicitly forward every dunder you care about
def __repr__(self) -> str:
return repr(object.__getattribute__(self, "_obj"))
def __str__(self) -> str:
return str(object.__getattribute__(self, "_obj"))
def __len__(self) -> int:
return len(object.__getattribute__(self, "_obj"))
def __iter__(self):
return iter(object.__getattribute__(self, "_obj"))
def __contains__(self, item) -> bool:
return item in object.__getattribute__(self, "_obj")
def __bool__(self) -> bool:
return bool(object.__getattribute__(self, "_obj"))
def __eq__(self, other) -> bool:
return object.__getattribute__(self, "_obj") == other
def __hash__(self) -> int:
return hash(object.__getattribute__(self, "_obj"))
proxy = TransparentProxy(Service())
print(repr(proxy)) # "Service(tag=service-v1)" - correct now
print(len(proxy)) # 42 - correct now
print(proxy.work()) # "done" - still works
Note that isinstance(proxy, Service) still returns False - that requires either __class__ property forwarding or registering with abc.ABCMeta. For most proxy use cases, making all operations behave correctly is sufficient; exact type identity is rarely needed.
Using __getattribute__ for Full Interception
If you need to intercept every attribute access (including _obj itself), use __getattribute__. This is more powerful but also more dangerous:
class LoggingAllProxy:
"""Logs every single attribute access including our own internal ones."""
def __init__(self, obj):
# Must bypass __getattribute__ to store our internal state
object.__setattr__(self, "_obj", obj)
object.__setattr__(self, "_access_log", [])
def __getattribute__(self, name: str):
# __getattribute__ is called for EVERY attribute access, including _obj
if name.startswith("_"):
# Retrieve our own internals without recursing
return object.__getattribute__(self, name)
obj = object.__getattribute__(self, "_obj")
log = object.__getattribute__(self, "_access_log")
log.append(name)
return getattr(obj, name)
def access_log(self) -> list[str]:
return object.__getattribute__(self, "_access_log")
proxy = LoggingAllProxy(Service())
proxy.work()
proxy.tag
proxy.work()
print(proxy.access_log()) # ['work', 'tag', 'work']
Remote Proxy
A remote proxy makes a network-resident service look like a local object:
from __future__ import annotations
import json
import urllib.request
from abc import ABC, abstractmethod
class RecommendationEngine(ABC):
@abstractmethod
def recommend(self, user_id: str, top_k: int = 5) -> list[str]: ...
class RemoteRecommendationProxy(RecommendationEngine):
"""
Makes a remote microservice appear as a local object.
Callers never know they are making HTTP calls.
"""
def __init__(self, base_url: str, api_key: str) -> None:
self._base_url = base_url.rstrip("/")
self._api_key = api_key
def recommend(self, user_id: str, top_k: int = 5) -> list[str]:
url = f"{self._base_url}/v1/recommendations?user_id={user_id}&top_k={top_k}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {self._api_key}",
"Accept": "application/json",
},
)
# Real code would handle timeouts, retries, and error responses
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
return data["items"]
Proxy Variants Reference
| Proxy type | Controls | Real-world example |
|---|---|---|
| Virtual | Object creation / initialisation | Lazy ML model loading |
| Protection | Access based on permissions | Role-based DB / API access |
| Remote | Network location | Microservice client stub |
| Caching | Repeated expensive computation | Memoised API calls |
| Logging | Call recording | Audit trail, observability |
| Smart Reference | Object lifecycle (ref counting) | Connection pool management |
Structural Patterns - Quick Comparison
| Pattern | "I reach for this when..." | Mechanism |
|---|---|---|
| Adapter | Third-party interface doesn't match mine | Wrap + translate |
| Bridge | Two independent axes need to vary separately | Composition across two hierarchies |
| Composite | I have recursive tree structures | Uniform interface; composites delegate |
| Decorator | I want to layer behaviour without subclassing | Stacked wrappers with same interface |
| Facade | A subsystem has too many moving parts | One coordinating class |
| Flyweight | I have millions of nearly-identical objects | Shared pool of intrinsic state |
| Proxy | I need to control access to an object | Surrogate with identical interface |
Interview Q&A
Q1: What is the difference between the Adapter pattern and the Facade pattern?
Adapter translates one interface into another - it is a 1:1 shim, typically applied after the fact to reconcile a third-party API you cannot modify with a target interface your code already expects. The work is structural translation. Facade simplifies access to a collection of subsystem interfaces by providing a single, purpose-built front door. The work is complexity reduction. A Facade often contains multiple Adapters internally. The key signal: if you are bridging incompatibility, it is Adapter; if you are reducing complexity, it is Facade.
Q2: Python's @decorator syntax and the GoF Decorator pattern - are they the same thing?
They are related in spirit but not equivalent. Python's @decorator syntax wraps a callable at definition time, typically returning a new callable. It does not preserve the type of the object being wrapped, and it operates at function/method granularity. The GoF Decorator pattern wraps object instances at runtime, with both the decorator and the wrapped object implementing the same interface so they are interchangeable. This enables transparent stacking: LoggingDecorator(RetryDecorator(RateLimitDecorator(RealClient()))) is still usable wherever HTTPClient is expected. The key GoF property - type-preserving, stackable, runtime composition - is absent from Python's function decorator syntax, though you can implement the GoF pattern using Python's @ syntax (e.g., class decorators).
Q3: Why does __getattr__ not intercept dunder method lookups, and what is the correct fix?
Python resolves special methods (dunder methods like __repr__, __len__, __iter__) by looking them up on the type of the object, not the instance, and not through the normal attribute lookup chain. This bypasses __getattr__ entirely. The rationale is performance: implicit dunder resolution happens in the hot path of many operations, and going through __getattr__ for every len() call would be too slow. The correct fix is to explicitly define each dunder method on the proxy class and forward it to the wrapped object. For comprehensive proxies, __getattribute__ provides full interception but requires careful use of object.__getattribute__ to avoid infinite recursion when accessing your own proxy's attributes.
Q4: When would you use Bridge instead of just Strategy?
Both involve holding a reference to an interchangeable implementation object. The distinction is conceptual scope. Strategy addresses one dimension of variation: the algorithm used for a single operation. Bridge addresses two independent dimensions simultaneously - both the abstraction hierarchy and the implementation hierarchy can grow independently. If you have a Notifier that needs both a channel (Email/SMS/Push) and a format (Plain/HTML/JSON), and both dimensions will expand over time, Bridge names the intent precisely. If you only have one dimension of variation (say, just the sorting algorithm), Strategy is the right vocabulary. Bridge is also designed upfront; Strategy can be applied retroactively.
Q5: Explain Flyweight's intrinsic vs extrinsic state split and why intrinsic state must be immutable.
Intrinsic state is the data that is the same across every use of a given flyweight - the properties that define the flyweight's identity (token text, POS tag, lemma). It is stored inside the flyweight object and shared. Extrinsic state is data that varies by context - a token's position in a document, the document ID, the sentence index. This is never stored in the flyweight; it is passed by the caller at the point of use or stored in a separate occurrence object. Intrinsic state must be immutable because the same flyweight object is simultaneously referenced by potentially millions of callers. If any caller could mutate it, all callers would see the mutation. In Python, @dataclass(frozen=True) combined with __slots__=True enforces immutability and eliminates the per-instance __dict__ overhead - together they are the canonical Flyweight implementation.
